

namespace ODataValidator.ValidationService
{
    using System;
    using System.Linq;
    using System.Collections.Generic;
    using System.Configuration;
    using System.Data.Services;
    using System.Data.Services.Common;
    using System.Diagnostics.CodeAnalysis;
    using System.ServiceModel.Web;
    using System.Threading;
    using System.Web;
    using Eucritta;

    /// <summary>ODataValidator class hosting the OData service for job submission and rule processing</summary>
    [System.ServiceModel.ServiceBehavior(IncludeExceptionDetailInFaults = true)]
    [JSONPSupportBehavior]
    public sealed class ODataValidator : DataService<ODataValidationSuiteEntities>, IDisposable
    {
        /// <summary>Name of the service</summary>
        public const string ServiceName = "odatavalidator";

        /// <summary>Maximum bytes of the payload accepted</summary>
        private static int MaxPayloadByteCount;

        /// <summary>Maximum size of the ValidationJobQueue</summary>
        private static int MaxValidationJobQueueSize;

        /// <summary>rulestore folder path for rules</summary>
        private static string RulestorePath;

        /// <summary>Folder path for extension rule assemblies</summary>
        private static string ExtensionStorePath;

        /// <summary>Represent an isntance of ValidationJobState</summary>
        private List<ValidationJobState> jobStates = new List<ValidationJobState>();

        /// <summary>Represent how many ValidationJobState is queued</summary>
        private int validationQueued = 0;

        /// <summary>Variable containing the credentials</summary>
        private System.Net.NetworkCredential credential;

        /// <summary>Constructor</summary>
        [SuppressMessage("Microsoft.Performance", "CA1810: AvoidStaticConstructor", Justification = "static member initialization has logic which oneliner cannot accommodate")]
        static ODataValidator()
        {
            ThreadPool.QueueUserWorkItem(new WaitCallback(ValidationJobQueueWorkers.ValidationQueueListener));

            if (!int.TryParse(ConfigurationManager.AppSettings["MaxPayloadByteCount"], out MaxPayloadByteCount))
            {
                // Set the maximum bytes of the payload accepted to 1 MB
                MaxPayloadByteCount = 1000000;
            }

            if (!int.TryParse(ConfigurationManager.AppSettings["MaxValidationJobQueueSize"], out MaxValidationJobQueueSize))
            {
                MaxValidationJobQueueSize = 20;
            }

            RulestorePath = ConfigurationManager.AppSettings["RulestorePath"] ?? "~/rulestore";
            ODataValidator.ExtensionStorePath = ConfigurationManager.AppSettings["ExtensionStorePath"] ?? "~/extensions";

            string serverMappedRulestorePath = HttpContext.Current.Server.MapPath(RulestorePath);
            string serverMappedExtensionStorePath = HttpContext.Current.Server.MapPath(ODataValidator.ExtensionStorePath);

            // Load the rule catalog from the rule store
            Guid initJobId = Guid.NewGuid();
            RuleEngine.ILogger logger = new DatabaseResultProvider(initJobId);
            RuleEngine.RuleStoreAsXmlFolder ruleStore = new RuleEngine.RuleStoreAsXmlFolder(serverMappedRulestorePath, logger);
            foreach (var r in ruleStore.GetRules())
            {
                RuleEngine.RuleCatalogCollection.Instance.Add(r);
            }

            // Load the rule catalog from the extension rule store
            RuleEngine.ExtensionRuleStore extensionStore = new RuleEngine.ExtensionRuleStore(serverMappedExtensionStorePath, logger);
            foreach (var rule in extensionStore.GetRules())
            {
                if (rule.IsValid())
                {
                    RuleEngine.RuleCatalogCollection.Instance.Add(rule);
                }
            }
        }

        /// <summary>Public constructor</summary>
        public ODataValidator()
        {
            this.ProcessingPipeline.ProcessingRequest += new EventHandler<DataServiceProcessingPipelineEventArgs>(this.ProcessingPipeline_ProcessingRequest);
        }

        /// <summary>Initialize the OData Service</summary>
        /// <param name="config">Service configuration</param>
        [SuppressMessage("Microsoft.Design", "CA1062: Validate input parameters befor using them", Justification = "framework ensures they have valid values")]
        public static void InitializeService(DataServiceConfiguration config)
        {
            config.SetEntitySetAccessRule("ValidationJobs", EntitySetRights.ReadSingle);
            config.SetEntitySetAccessRule("ExtValidationJobs", EntitySetRights.WriteAppend);
            config.SetEntitySetAccessRule("JobGroups", EntitySetRights.AllRead);
            config.SetEntitySetAccessRule("TestResults", EntitySetRights.ReadMultiple);
            config.SetEntitySetAccessRule("PayloadLines", EntitySetRights.ReadMultiple);
            config.SetServiceOperationAccessRule("UriValidationJobs", ServiceOperationRights.ReadMultiple);
            config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V2;
            config.UseVerboseErrors = true;
        }

        /// <summary>
        /// Returns a collection of validation jobs from one validation request
        /// </summary>
        /// <param name="Uri">The Uri input of the validation request</param>
        /// <param name="Format">The format hint of the validation request</param>
        /// <param name="toCrawl">indicating whether the request is crawling ("yes") or not (other than "yes") </param>
        /// <param name="Headers">The Http request headers that will be sent out as part of request</param>
        /// <param name="UserId">User login (valid only if the service needs to be authenticated)</param>
        /// <param name="Password">User password (valid only if the service needs to be authenticated)</param>
        /// <returns>The collection of validation jobs</returns>
        [WebGet]
        public IEnumerable<JobGroup> UriValidationJobs(string Uri, string Format, string toCrawl, string Headers, string UserId, string Password)
        {
            Uri = HttpUtility.UrlDecode(Uri);
            Format = HttpUtility.UrlDecode(Format);
            toCrawl = HttpUtility.UrlDecode(toCrawl);

            List<KeyValuePair<string, string>> reqHeaders = ToHeaderCollection(Headers);

            //Build user credentials ...
            UserId = HttpUtility.UrlDecode(UserId);
            Password = HttpUtility.UrlDecode(Password);
            if (String.IsNullOrEmpty(UserId) && String.IsNullOrEmpty(Password))
                this.credential = null;
            else
                this.credential = new System.Net.NetworkCredential(UserId, Password, "");

            if (!string.IsNullOrEmpty(toCrawl) && toCrawl.Equals("yes", StringComparison.OrdinalIgnoreCase))
            {
                return CreateCrawlingJobsByUri(Uri, Format, reqHeaders, this.credential);
            }
            else
            {
                return CreateSimpleValidationJobByUri(Uri, Format, reqHeaders, this.credential);
            }
        }

        private static List<KeyValuePair<string, string>> ToHeaderCollection(string Headers)
        {
            List<KeyValuePair<string, string>> reqHeaders = null;
            if (!string.IsNullOrEmpty(Headers))
            {
                reqHeaders = new List<KeyValuePair<string, string>>();
                string[] lines = Headers.Split(';');
                foreach (var line in lines)
                {
                    string[] pair = line.Split(new char[] { ':' }, 2);
                    if (pair.Length == 2)
                    {
                        reqHeaders.Add(new KeyValuePair<string, string>(pair[0], HttpUtility.UrlDecode(pair[1])));
                    }
                    else if (pair.Length == 1)
                    {
                        reqHeaders.Add(new KeyValuePair<string, string>(pair[0], null));
                    }
                }
            }
            return reqHeaders;
        }


        /// <summary>Initialize a new validation job</summary>
        /// <param name="job">Row of ExtValidationJobs table(updateable view)</param>
        /// <param name="op">UpdateOperations</param>
        [ChangeInterceptor("ExtValidationJobs")]
        public void OnInsertJob(ExtValidationJobs job, UpdateOperations op)
        {
            if (job == null)
            {
                throw new ArgumentNullException("job");
            }

            // ensure only insert ops are allowed
            ValidateOperationType(op);

            // limit total size of validation job queue
            CheckValidationJobQueueSize();

            job.Complete = false;
            job.CreatedDate = DateTime.Now;
            job.ID = Guid.NewGuid();

            // instantiate validation engine
            var ctx = CreateRuleEngineContext(job, this.credential);
            RuleEngine.IResultProvider resultProvider = new DatabaseResultProvider(job.ID);
            RuleEngine.ILogger logger = resultProvider as RuleEngine.ILogger;
            RuleEngine.RuleEngineWrapper ruleEngine = new RuleEngine.RuleEngineWrapper(ctx, resultProvider, logger);
            
            // set total # of rules to execute for the given URI & format
            job.RuleCount = ruleEngine.RuleCount;

            LogJobRespHeaders(job, ctx);
            AddPayloadLinesToJob(job, ctx);

            // state object is used to start the validation workflow on a threadpool thread 
            this.InitJobState(ruleEngine, job.ID);
        }

        /// <summary>Dispose</summary>
        public void Dispose()
        {
            foreach (var j in this.jobStates)
            {
                Interlocked.Increment(ref this.validationQueued);
                if (j != null && j.RuleEngine != null)
                {
                    ValidationJobQueue.EnqueueJob(j);
                }
            }
        }

        /// <summary>Exception handling</summary>
        /// <param name="args">args</param>
        [SuppressMessage("Microsoft.Design", "CA1062: Validate input parameters befor using them", Justification = "framework ensures they have valid values")]
        [SuppressMessage("Microsoft.Naming", "CA2204: Correct the spelling of the unrecognized token 'OData'", Justification = "OData is protocol name")]
        protected override void HandleException(HandleExceptionArgs args)
        {
            // turn unexpected errors into generic 500 error response
            if (args.Exception != null && args.Exception.InnerException != null && args.Exception.InnerException is RuleEngine.Common.OversizedPayloadException)
            {
                // turn an oversized payload exception into HTTP error 509 Bandwidth Limit Exceeded
                throw new DataServiceException(509, args.Exception.InnerException.Message);
            }
            else if (args.Exception != null && args.Exception.InnerException != null && args.Exception.InnerException is System.ArgumentException)
            {
                if (((System.ArgumentException)args.Exception.InnerException).ParamName.Equals("destination", StringComparison.OrdinalIgnoreCase))
                {
                    // turn this ArgumentException exception (thrown for unregisted uri schema) into HTTP error 403 Forbidden
                    throw new DataServiceException(403, args.Exception.InnerException.Message);
                }
            }
            
            if (args.Exception.GetType() != typeof(DataServiceException))
            {
                throw new DataServiceException(500, "Oops. An unexpected error occurred while trying to validate your OData endpoint.");
            }
        }

        /// <summary>Check the operation type</summary>
        /// <param name="op">UpdateOperations</param>
        private static void ValidateOperationType(UpdateOperations op)
        {
            // ensure only insert operations are supported
            if (op != UpdateOperations.Add || HttpContext.Current.Request.HttpMethod.ToUpperInvariant() != "POST")
            {
                throw new DataServiceException(405, "Method Not Allowed");
            }
        }

        /// <summary>Check the size of ValidationJobQueue</summary>
        private static void CheckValidationJobQueueSize()
        {
            if (ValidationJobQueue.Count > MaxValidationJobQueueSize)
            {
                throw new DataServiceException(503, "Server Busy - Lots of validation going on at the moment. Please retry.");
            }
        }

        private static void AddPayloadLinesToJobInImmediateDBEnv(ExtValidationJobs job, RuleEngine.ServiceContext ctx)
        {
            using (var x = new ODataValidationSuiteEntities())
            {
                var j = x.ExtValidationJobs;
                j.AddObject(job);

                if (ctx.PayloadType == RuleEngine.PayloadType.RawValue && ctx.ContentType == RuleEngine.PayloadFormat.Image)
                {
                    PayloadLine payloadLine = new PayloadLine();
                    payloadLine.ID = Guid.NewGuid();
                    payloadLine.LineNumber = 1;
                    payloadLine.LineText = "( Image data )";
                    job.PayloadLines.Add(payloadLine);
                }
                else
                {
                    PayloadLine payloadLine;
                    int lineNumber = 0;
                    foreach (var responseLine in ctx.GetPayloadLines())
                    {
                        payloadLine = new PayloadLine();
                        payloadLine.ID = Guid.NewGuid();
                        payloadLine.LineNumber = ++lineNumber;
                        payloadLine.LineText = responseLine;
                        job.PayloadLines.Add(payloadLine);
                    }
                }

                x.SaveChanges();
            }
        }

        /// <summary>
        /// Logs response Http headers for the interop validation context to persistent storage
        /// </summary>
        /// <param name="job">The validation job object</param>
        /// <param name="ctx">The interop validation context</param>
        private static void LogJobRespHeaders(ExtValidationJobs job, RuleEngine.ServiceContext ctx)
        {
            if (!string.IsNullOrEmpty(ctx.ResponseHttpHeaders))
            {
                using (var x = new ODataValidationSuiteEntities())
                {
                    var j = x.JobData;
                    JobData jobData = new JobData();
                    jobData.ID = Guid.NewGuid();
                    jobData.RespHeaders = ctx.ResponseHttpHeaders;
                    jobData.JobID = job.ID;

                    j.AddObject(jobData);
                    x.SaveChanges();
                }            
            }
        }

        /// <summary>Add the payload lines to the PayloadLines table</summary>
        /// <param name="job">Row of ValidationJob</param>
        /// <param name="ctx">ServiceContext</param>
        private static void AddPayloadLinesToJob(ExtValidationJobs job, RuleEngine.ServiceContext ctx)
        {
            if (ctx.PayloadType == RuleEngine.PayloadType.RawValue && ctx.ContentType == RuleEngine.PayloadFormat.Image)
            {
                PayloadLine payloadLine = new PayloadLine();
                payloadLine.ID = Guid.NewGuid();
                payloadLine.LineNumber = 1;
                payloadLine.LineText = "( Image data )";
                job.PayloadLines.Add(payloadLine);
            }
            else
            {
                PayloadLine payloadLine;
                int lineNumber = 0;
                foreach (var responseLine in ctx.GetPayloadLines())
                {
                    payloadLine = new PayloadLine();
                    payloadLine.ID = Guid.NewGuid();
                    payloadLine.LineNumber = ++lineNumber;
                    payloadLine.LineText = responseLine;
                    job.PayloadLines.Add(payloadLine);
                }
            }
        }

        /// <summary>Create the ServiceContext based on the ValidationJob</summary>
        /// <param name="job">Row of ValidationJob in the SQL table</param>
        /// <param name="credential">Credentials for the service or null in case the service doesn't need to be authenticated</param>
        /// <returns>ServiceContext class</returns>
        private static RuleEngine.ServiceContext CreateRuleEngineContext(ExtValidationJobs job, System.Net.NetworkCredential credential)
        {
            if (!string.IsNullOrEmpty(job.Uri))
            {
                // an online validation
                return RuleEngine.ServiceContextFactory.Create(job.Uri, job.Format, job.ID, MaxPayloadByteCount, ToHeaderCollection(job.ReqHeaders), credential);
            }
            else
            {
                // an offline validation
                if (job.PayloadText.Length > MaxPayloadByteCount || job.MetadataText.Length > MaxPayloadByteCount)
                {
                    throw new DataServiceException(509, "Content exceeds the allowed maximum size. Please copy and paste smaller content and retry.");
                }
                string payloadText = SanitizeXmlLiteral(job.PayloadText);
                string metadataText = SanitizeXmlLiteral(job.MetadataText);
                return RuleEngine.ServiceContextFactory.Create(payloadText, metadataText, job.ID, job.ReqHeaders);
            }
        }

        /// <summary>
        /// Limits allowable paths to /ValidationJobs(id), /ValidationJobs(id)/TestResults, /ValidationJobs(id)/PayloadLines 
        /// This path scoping is done such that users cannot query on information not relevant to the job they submitted.
        /// Finally, this disallows use of $filter for now to limit cost per request
        /// </summary>
        /// <param name="sender">sender</param>
        /// <param name="e">e</param>
        private void ProcessingPipeline_ProcessingRequest(object sender, DataServiceProcessingPipelineEventArgs e)
        {
            int i;
            for (i = 0; i < e.OperationContext.AbsoluteRequestUri.Segments.Length; i++)
            {
                if (e.OperationContext.AbsoluteRequestUri.Segments[i].ToUpperInvariant().StartsWith(ServiceName, StringComparison.OrdinalIgnoreCase))
                {
                    break;
                }
            }

            // i at index of segment immediately after the service root segment
            i++;
            if (i < e.OperationContext.AbsoluteRequestUri.Segments.Length &&
                    !e.OperationContext.AbsoluteRequestUri.Segments[i].ToUpperInvariant().StartsWith("ExtValidationJobs", StringComparison.OrdinalIgnoreCase) &&
                    !e.OperationContext.AbsoluteRequestUri.Segments[i].ToUpperInvariant().StartsWith("validationjobs", StringComparison.OrdinalIgnoreCase) &&
                    !e.OperationContext.AbsoluteRequestUri.Segments[i].ToUpperInvariant().StartsWith("$metadata", StringComparison.OrdinalIgnoreCase) &&
                    !e.OperationContext.AbsoluteRequestUri.Segments[i].ToUpperInvariant().StartsWith("UriValidationJobs", StringComparison.OrdinalIgnoreCase)
                )
            {
                throw new DataServiceException(400, "Test result and payload line sets must be accessed via an associated validation job");
            }

            // for now limiting complexity of queries the service is willing to run
            if (e.OperationContext.AbsoluteRequestUri.Query != null &&
                e.OperationContext.AbsoluteRequestUri.Query.Contains("$filter"))
            {
                throw new DataServiceException(400, "This service does not currently support $filter requests");
            }
        }

        /// <summary>Initialize a new instance ValidationJobState</summary>
        /// <param name="ruleEngine">Rule engine</param>
        /// <param name="jobId">Guid</param>
        private void InitJobState(RuleEngine.RuleEngineWrapper ruleEngine, Guid jobId)
        {
             var jobState = new ValidationJobState();
             jobState.RuleEngine = ruleEngine;
             jobState.JobId = jobId;
             this.jobStates.Add(jobState);
        }

        static private string SanitizeXmlLiteral(string input)
        {
            return input.Trim(' ', '\t', '\r', '\n');
        }

        private IEnumerable<JobGroup> CreateSimpleValidationJobByUri(string Uri, string Format, IEnumerable<KeyValuePair<string, string>> reqHeaders, System.Net.NetworkCredential credential)
        {
            RuleEngine.ServiceContext ctx = RuleEngine.ServiceContextFactory.Create(Uri, Format, Guid.NewGuid(), MaxPayloadByteCount, reqHeaders, credential);
            return new JobGroup[] { CreateJobGroupItem(Format, ctx.JobId, ctx) };
        }

        private IEnumerable<JobGroup> CreateCrawlingJobsByUri(string Uri, string Format, IEnumerable<KeyValuePair<string, string>> reqHeaders, System.Net.NetworkCredential credential)
        {
            RuleEngine.ServiceContext ctxMaster = RuleEngine.ServiceContextFactory.Create(Uri, Format, Guid.NewGuid(), MaxPayloadByteCount, reqHeaders, credential);

            // make sure the master target is a serveice doc resource
            if (ctxMaster.PayloadType != RuleEngine.PayloadType.ServiceDoc)
            {
                return new JobGroup[]
                    {
                        new JobGroup(){
                            Uri = Uri,
                            MasterJobId = Guid.Empty,
                            DerivativeJobId = Guid.Empty,
                            ResourceType = (byte)ctxMaster.PayloadType,
                            RuleCount = 0,
                            Issues = "does not return the expected service document resource.",
                        }
                    };
            }

            SimpleJobPlanner jobPlanner = new SimpleJobPlanner(ctxMaster, Format, MaxPayloadByteCount);
            List<KeyValuePair<string, string>> failures;
            var jobs = jobPlanner.GetPlannedJobs(out failures);
            List<JobGroup> jobGroup = new List<JobGroup>();

            foreach (var job in jobs)
            {
                jobGroup.Add(CreateJobGroupItem(Format, ctxMaster.JobId, job));
            }

            if (failures != null)
            {
                foreach (var fail in failures)
                {
                    var item = JobGroup.CreateJobGroup(ctxMaster.JobId, 0, 0, fail.Key);
                    item.Issues = fail.Value;
                    jobGroup.Add(item);
                }
            }
            return jobGroup;
        }

        private JobGroup CreateJobGroupItem(string format, Guid masterJobId, RuleEngine.ServiceContext ctx)
        {
            RuleEngine.IResultProvider resultProvider = new DatabaseResultProvider(ctx.JobId);
            RuleEngine.ILogger logger = resultProvider as RuleEngine.ILogger;
            RuleEngine.RuleEngineWrapper ruleEngine = new RuleEngine.RuleEngineWrapper(ctx, resultProvider, logger);

            // start the validation workflow on the backend threadpool thread 
            this.InitJobState(ruleEngine, ctx.JobId);

            // insert validation job entry, and insert payload lines, to underlying DB
            ExtValidationJobs extJob = new ExtValidationJobs();
            extJob.ID = ctx.JobId;
            extJob.Complete = false;
            extJob.CreatedDate = DateTime.Now;
            extJob.Uri = ctx.Destination.OriginalString;
            extJob.Format = format;
            extJob.RuleCount = ruleEngine.RuleCount;
            if (ctx.RequestHeaders != null && ctx.RequestHeaders.Any())
            {
                var headers = "";
                foreach(var p in ctx.RequestHeaders)
                {
                    headers += p.Key + ":" + p.Value + ";";
                }
                extJob.ReqHeaders = headers;
            }
            AddPayloadLinesToJobInImmediateDBEnv(extJob, ctx);
            LogJobRespHeaders(extJob, ctx);

            // populate the data to return
            JobGroup item = JobGroup.CreateJobGroup(masterJobId, (byte)ctx.PayloadType, ruleEngine.RuleCount, ctx.Destination.OriginalString);
            item.DerivativeJobId = ctx.JobId;

            return item;
        }
    }
}
